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 * 1024173internal val MAX_PADDED_SNAPSHOT_FRAME_BYTES = SyncPadding.bucket(SNAPSHOT_FRAME_WIRE_CEILING + 4)174175176177178179180181const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 182const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000183const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000184185const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH186187188189190191const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES192193194195196197class AdoptSnapshotBudget {198 val entries = LinkedHashMap<String, String>()199 val tombstones = LinkedHashMap<String, Long>()200 var duressSlot: String? = null201 202 var bytes: Int = 0203 var entryCount: Int = 0204 var tombstoneCount: Int = 0205}206207fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()208209210211212213214215216217218219fun applyAdoptSnapshotFrame(220 acc: AdoptSnapshotBudget,221 entries: Map<String, String>,222 tombstones: Map<String, Long>,223 duressSlot: String?,224 frameBytes: Int,225) {226 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {227 throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")228 }229 for ((id, envelope) in entries) {230 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")231 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {232 throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")233 }234 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {235 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")236 }237 acc.entryCount++238 acc.entries[id] = envelope239 }240 for ((id, ts) in tombstones) {241 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")242 243 244 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {245 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")246 }247 acc.tombstoneCount++248 acc.tombstones[id] = ts249 }250 if (!duressSlot.isNullOrEmpty()) {251 252 253 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {254 throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")255 }256 acc.duressSlot = duressSlot257 }258 acc.bytes += frameBytes259}260261262interface AdoptionReceiverDeps {263 val deviceName: String264 val deviceType: DeviceType265 fun generateDeviceId(): String266 267 suspend fun confirmSas(): Boolean268 269270271272 suspend fun validateSource(source: AdoptionFrame.Source) {}273274 275 suspend fun createVaultFromRootKey(rootKeyHex: String): String276 suspend fun deriveLocalIdentity(): LocalDeviceIdentity277 278 suspend fun clearPairedDeviceRevocation(deviceId: String) {}279 suspend fun upsertPairedDevice(device: PairedDevice)280 suspend fun applyLicense(license: String) {}281 282 suspend fun license(): String? = null283 val pairState: AdoptionPairState? get() = null284 fun onError(error: String)285 286 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}287 288289290291292293294295 suspend fun applyIncomingDeviceManifest(296 manifest: SignedDeviceManifest,297 authenticatedRemoteStaticPublicKey: ByteArray,298 ) {}299 300 suspend fun markSynced(deviceId: String) {}301}302303304interface AdoptionSourceDeps {305 val sourceIdentity: LocalDeviceIdentity306 suspend fun rootKeyHex(): String307 suspend fun vaultId(): String308 suspend fun license(): String? = null309 310 suspend fun applyLicense(license: String) {}311312 313314315316317318 suspend fun onConsent(request: AdoptionFrame.Request): Boolean319 suspend fun upsertPairedDevice(device: PairedDevice)320 val pairState: AdoptionPairState? get() = null321 322 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null323 324 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null325 326 suspend fun markSynced(deviceId: String) {}327 328 suspend fun duressSlotJson(): String? = null329}330331private val Hex64 = Regex("^[0-9a-f]{64}\$")332333fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)334335fun isAdoptRequest(frame: AdoptionFrame?): Boolean =336 frame is AdoptionFrame.Request &&337 frame.sessionRequestId.isNotEmpty() &&338 isDeviceType(frame.deviceType)339340fun isAdoptGrant(frame: AdoptionFrame?): Boolean =341 frame is AdoptionFrame.Grant &&342 validateRootKeyHex(frame.rootKeyHex) &&343 Hex64.matches(frame.vaultId) &&344 isDeviceType(frame.source.deviceType) &&345 Hex64.matches(frame.source.longTermPublicKeyHex) &&346 (frame.source.relayUrl == null ||347 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)348349fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =350 frame is AdoptionFrame.Confirm351352fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =353 frame is AdoptionFrame.Identity &&354 frame.deviceId.isNotEmpty() &&355 isDeviceType(frame.deviceType) &&356 Hex64.matches(frame.longTermPublicKeyHex)357358fun isAdoptComplete(frame: AdoptionFrame?): Boolean =359 frame is AdoptionFrame.Complete360361fun isAdoptApplied(frame: AdoptionFrame?): Boolean =362 frame is AdoptionFrame.Applied363364fun isAdoptReject(frame: AdoptionFrame?): Boolean =365 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"366367fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =368 frame is AdoptionFrame.Snapshot &&369 frame.chunkIndex >= 0 &&370 frame.chunkCount >= 1371372373374375376377378379suspend fun requestAdoption(380 channel: PairingTransport,381 deps: AdoptionReceiverDeps,382 383 384 385 386 387 onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,388 nowMs: () -> Long = { System.currentTimeMillis() },389): PairedDevice? {390 val receiverLicense = deps.license()391 392 393 sendFrame(channel, AdoptionFrame.Request(394 sessionRequestId = deps.generateDeviceId(),395 deviceName = deps.deviceName,396 deviceType = deps.deviceType.wireValue,397 license = receiverLicense,398 snapshotCapable = true,399 ))400401 val confirmed = deps.confirmSas()402 sendFrame(channel, AdoptionFrame.Confirm(confirmed))403 if (!confirmed) {404 deps.onError("ADOPT_SAS_REJECTED")405 return null406 }407408 val frame = receiveFrame(channel)409 if (isAdoptReject(frame)) {410 deps.onError((frame as AdoptionFrame.Reject).reason)411 return null412 }413 if (!isAdoptGrant(frame)) {414 deps.onError("ADOPT_UNEXPECTED_FRAME")415 return null416 }417 val grant = frame as AdoptionFrame.Grant418 onGrant?.invoke(grant)419420 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)421 try {422 if (computeVaultId(rootKeyBytes) != grant.vaultId) {423 deps.onError("ADOPT_VAULT_ID_MISMATCH")424 return null425 }426 } finally {427 rootKeyBytes.fill(0)428 }429 if (receiverLicense == null && grant.license == null) {430 deps.onError("ADOPT_PRO_REQUIRED")431 return null432 }433434 deps.validateSource(grant.source)435 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)436 if (vaultId != grant.vaultId) {437 deps.onError("ADOPT_VAULT_ID_MISMATCH")438 return null439 }440441 if (receiverLicense == null) {442 grant.license?.let { deps.applyLicense(it) }443 }444445 val identity = deps.deriveLocalIdentity()446 sendFrame(channel, AdoptionFrame.Identity(447 deviceId = identity.deviceId,448 deviceName = identity.deviceName,449 deviceType = identity.deviceType.wireValue,450 longTermPublicKeyHex = identity.longTermPublicKeyHex,451 signingPublicKeyHex = identity.signingPublicKeyHex,452 ))453454 if (!isAdoptComplete(receiveFrame(channel))) {455 deps.onError("ADOPT_COMPLETION_MISSING")456 return null457 }458459 val sourceDevice = PairedDevice(460 deviceId = grant.source.deviceId,461 deviceName = grant.source.deviceName,462 deviceType = grant.source.deviceType,463 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,464 signingPublicKeyHex = grant.source.signingPublicKeyHex,465 trustSource = "direct",466 wsPort = grant.source.wsPort,467 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,468 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,469 pairedAt = nowMs(),470 )471 472 473 474 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)475 deps.upsertPairedDevice(sourceDevice)476 sendFrame(channel, AdoptionFrame.Applied)477 return sourceDevice478}479480481482483484485suspend fun grantAdoption(486 channel: PairingTransport,487 request: AdoptionFrame.Request,488 deps: AdoptionSourceDeps,489 nowMs: () -> Long = { System.currentTimeMillis() },490): PairedDevice? {491 if (!deps.onConsent(request)) return null492493 val confirmation = receiveFrame(channel)494 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {495 return null496 }497498 var sourceLicense = deps.license()499 if (sourceLicense == null && request.license != null) {500 sourceLicense = runCatching {501 deps.applyLicense(request.license)502 deps.license()503 }.getOrNull()504 }505 if (sourceLicense == null) {506 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))507 throw IllegalStateException("ADOPT_PRO_REQUIRED")508 }509510 val rootKeyHex = deps.rootKeyHex()511 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")512513 sendFrame(channel, AdoptionFrame.Grant(514 rootKeyHex = rootKeyHex,515 vaultId = deps.vaultId(),516 license = sourceLicense,517 source = AdoptionFrame.Source(518 deviceId = deps.sourceIdentity.deviceId,519 deviceName = deps.sourceIdentity.deviceName,520 deviceType = deps.sourceIdentity.deviceType.wireValue,521 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,522 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,523 wsPort = deps.sourceIdentity.wsPort,524 relayUrl = deps.pairState?.relayUrl,525 ),526 snapshotCapable = request.snapshotCapable == true,527 ))528529 val identity = receiveFrame(channel)530 if (!isAdoptIdentity(identity)) return null531 identity as AdoptionFrame.Identity532533 val device = PairedDevice(534 deviceId = identity.deviceId,535 deviceName = identity.deviceName,536 deviceType = identity.deviceType,537 longTermPublicKeyHex = identity.longTermPublicKeyHex,538 signingPublicKeyHex = identity.signingPublicKeyHex,539 trustSource = "direct",540 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,541 relayUrl = deps.pairState?.relayUrl,542 pairedAt = nowMs(),543 )544 deps.upsertPairedDevice(device)545 sendFrame(channel, AdoptionFrame.Complete)546 if (!isAdoptApplied(receiveFrame(channel))) return null547 return device548}549550551552553554555val AdoptJson: Json = Json {556 classDiscriminator = "type"557 encodeDefaults = false558 ignoreUnknownKeys = true559}560561suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {562 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))563}564565566suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =567 runCatching {568 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))569 }.getOrNull()570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604suspend fun sendVaultSnapshot(605 channel: PairingTransport,606 entries: Map<String, String>,607 tombstones: Map<String, Long>,608 duressSlot: String? = null,609) {610 val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)611 612 613 614 615 616 val acc = createAdoptSnapshotBudget()617 val encoded = frames.map {618 AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()619 }620 for (i in frames.indices) {621 val frame = frames[i]622 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)623 }624 for (raw in encoded) channel.send(SyncPadding.pad(raw))625}626627628629630631632private fun buildAdoptSnapshotFrames(633 entries: Map<String, String>,634 tombstones: Map<String, Long>,635 duressSlot: String?,636): List<AdoptionFrame.Snapshot> {637 val includeDuress = !duressSlot.isNullOrBlank()638 639 640 641 642 643 val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS644645 val chunks = mutableListOf<Map<String, String>>()646 var current = LinkedHashMap<String, String>()647 var currentBytes = 2 648 for ((id, envelope) in entries) {649 650 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1651 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {652 chunks.add(current)653 current = LinkedHashMap()654 currentBytes = 2655 }656 current[id] = envelope657 currentBytes += entryBytes658 }659 chunks.add(current)660661 val entriesChunkCount = minOf(chunks.size, entryCap)662 val sendDuress = includeDuress663 val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)664 val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(665 chunkIndex = entriesChunkCount,666 chunkCount = totalChunks,667 entries = emptyMap(),668 tombstones = tombstones,669 duressSlot = duressSlot,670 ) else null671672 if (673 duressFrame != null &&674 AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)675 .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING676 ) {677 throw SnapshotDuressFrameTooLargeException()678 }679680 val frames = mutableListOf<AdoptionFrame.Snapshot>()681 for (index in 0 until entriesChunkCount) {682 val isFinalEntries = index == entriesChunkCount - 1683 frames.add(AdoptionFrame.Snapshot(684 chunkIndex = index,685 chunkCount = totalChunks,686 entries = chunks[index],687 688 689 690 tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),691 duressSlot = null,692 ))693 }694695 if (duressFrame != null) frames.add(duressFrame)696 return frames697}698699internal class SnapshotDuressFrameTooLargeException :700 IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")701702703704705706707708709710711712713suspend fun receiveVaultSnapshot(714 channel: PairingTransport,715): ReceivedSnapshot {716 val acc = createAdoptSnapshotBudget()717 var chunkCount = -1718 var received = 0719 while (chunkCount == -1 || received < chunkCount) {720 val wire = channel.receive()721 if (wire.size > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {722 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")723 }724 val raw = runCatching { SyncPadding.unpad(wire) }725 .getOrElse { throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME", it) }726 if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {727 throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")728 }729 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {730 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")731 }732 val frame = runCatching {733 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))734 }.getOrNull()735 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")736 frame as AdoptionFrame.Snapshot737 if (chunkCount == -1) {738 chunkCount = frame.chunkCount739 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {740 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")741 }742 } else if (frame.chunkCount != chunkCount) {743 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")744 }745 if (frame.chunkIndex != received) {746 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")747 }748 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)749 received++750 }751 return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)752}753754private fun isDeviceType(value: String): Boolean =755 DeviceType.entries.any { it.wireValue == value }